# __HEADER__

"""XXX

- Case-insensitive attribute access.  Consider ldap.cidict
- Given a model instance, get its children (of appropriate model types).
- Easy way to get by distinguished name?
- Support moving in a tree (newsuperior), future.
- At this time, changing objectClass is not supported.
"""

import ldap.dn
import ldap.schema
import oldap.cache
import oldap.callback
import oldap.exceptions
import oldap.filter
import oldap.reference
from oldap.cache import get_connection
from oldap.dn import RDN

_schema = None

def _get_schema():
    global _schema
    if _schema is not None:
        return _schema
    # Consider having a separate URI for fetching schema
    # (for permissions or whatever).
    _schema = ldap.schema.urlfetch(oldap.cache.LDAP_URI)[1]
    return _schema

class Schema(object):

    """XXX

    attributes -- dictionary - name to Attribute (no aliases)

    attribute_aliases -- dictionary:
        non-alias -> root
        alias -> root
    """

    def __init__(self):
        self.attributes = {}
        self.attribute_aliases = {}

def _dupe_value(value):
    if isinstance(value, list):
        return value[:]
    else:
        return value

class MetaModel(type):

    """XXX"""

    def __new__(cls, name, bases, dct):
        new_class = type.__new__(cls, name, bases, dct)
        # Create bound object filter.
        if 'objects' not in dct:
            objects = oldap.filter.Filter()
            objects._bind(new_class)
            new_class.objects = objects

        references = {}
        for attr_name, attr_value in dct.items():
            if isinstance(attr_value, oldap.reference.Reference):
                references[attr_name] = attr_value
                attr_value.meta_init(new_class, attr_name)
        new_class._references = references

        return new_class

class Model(object):

    """XXX"""

    __metaclass__ = MetaModel

    # XXX: Subclasses should define these
    # XXX: Hide within Meta inner class.  Need to think about inheritence (metaclass?).
    base_dn = ''
    objectClass = ()
    rdn_attributes = ()

    # Internal information.
    _schema = None
    _rdn = None     # An RDN instance.
    _references = None # Dictionary, attr_name: Reference
    _referrers = None # List of Reference instances that refer to us.

    # This is only set on save or load.
    _original_values = None
    _original_dn = None # A string.
    _original_rdn = None # An RDN instance.

    def __init__(self, **kwargs):
        if self.__class__._schema is None:
            self.__class__._init_schema()
        rdn = None
        for attr_name, attr_value in kwargs.items():
            if attr_name == 'rdn':
                rdn = attr_value
            else:
                setattr(self, attr_name, attr_value)
        # Set last to deal with interspersed attributes of the same type that
        # have values not involved with rdn.
        if rdn is not None:
            self.rdn = rdn

    @classmethod
    def _init_schema(cls):
        schema = _get_schema()
        s = Schema()

        # Follow all of the LDAP SUP entries to find all attributes.
        def collect_classes(schema, object_class, result):
            for sup_name in object_class.sup:
                sup_class = schema.get_obj(ldap.schema.ObjectClass, sup_name)
                collect_classes(schema, sup_class, result)
            result.append(object_class)

        for class_name in cls.objectClass:
            object_class = schema.get_obj(ldap.schema.ObjectClass, class_name)
            all_classes = []
            collect_classes(schema, object_class, all_classes)
            for object_class in all_classes:
                for attr_name in object_class.must + object_class.may:
                    attr = schema.get_obj(ldap.schema.AttributeType, attr_name)
                    if 'objectClass' not in attr.names:
                        s.attributes[attr.names[0]] = attr
                        for name in attr.names:
                            s.attribute_aliases[name] = attr
                            setattr(cls, name, AttrDescriptor(attr))
                # Beware that attributes of schema objects do not inherit
                # the settings of their superiors.  single-value does not
                # appear to be an inherited value in openldap.
        # For now, objectClass is read-only, otherwise we would have to
        # dynamically update the descriptors.
        cls.objectClass = ReadOnlyDescriptor(cls.objectClass)
        cls._schema = s

    def _snapshot_original(self):
        original_values = {}
        for attr_name, attr_value in self.__dict__.items():
            if attr_name in self._schema.attributes and attr_name != 'objectClass':
                original_values[attr_name] = _dupe_value(attr_value)
        self._original_values = original_values
        self._original_dn = self.dn
        self._original_rdn = self._rdn

    def __eq__(self, other):
        if other is self:
            return True
        if not isinstance(other, self.__class__):
            return False
        if self.dn != other.dn:
            return False
        # This seems disturbingly inefficient.
        for attr_name, attr_schema in self._schema.attributes.items():
            if attr_schema.single_value:
                default = None
            else:
                default = []
            attr_value = self.__dict__.get(attr_name, default)
            other_value = other.__dict__.get(attr_name, default)
            if isinstance(attr_value, list):
                if sorted(attr_value) != sorted(other_value):
                    return False
            else:
                if attr_value != other_value:
                    return False
        return True

    def __ne__(self, other):
        return not self.__eq__(other)

    def __hash__(self):
        return hash(self.dn)

    def __repr__(self):
        return '<%s dn=%r>' % (self.__class__.__name__, self.dn)

    @property
    def rdn(self):
        if self._rdn is None:
            # This is somewhat guesswork.
            pieces = []
            for attr_name in self.rdn_attributes:
                try:
                    value = self.__dict__[attr_name]
                except KeyError:
                    raise AssertionError('RDN attribute %s is not yet set.' % (attr_name,))
                if isinstance(value, list):
                    for v in value:
                        pieces.append((attr_name, v))
                else:
                    pieces.append((attr_name, value))
        else:
            # This does not check if you have (illegally) changed a value.
            pieces = self._rdn.attributes.items()
        escaped = ['%s=%s' % (attr, ldap.dn.escape_dn_chars(value)) for attr, value in pieces]
        return '+'.join(escaped)

    @rdn.setter
    def rdn(self, value):
        if not isinstance(value, RDN):
            value = RDN(value)

        # Remove current RDN values (if they are still there).
        if self._rdn is not None:
            for attr_name, attr_value in self._rdn.attributes.items():
                try:
                    current_value = self.__dict__[attr_name]
                except KeyError:
                    # Ignore.
                    pass
                else:
                    if isinstance(current_value, list):
                        try:
                            current_value.remove(attr_value)
                        except ValueError:
                            pass
                    else:
                        setattr(self, attr_name, None)
        # Set new values.
        self._rdn = value
        for attr_name, attr_value in value.attributes.items():
            try:
                current_value = self.__dict__[attr_name]
            except KeyError:
                if self._schema.attribute_aliases[attr_name].single_value:
                    setattr(self, attr_name, attr_value)
                else:
                    setattr(self, attr_name, [attr_value])
            else:
                if isinstance(current_value, list):
                    current_value.append(attr_value)
                else:
                    setattr(self, attr_name, attr_value)

    @property
    def dn(self):
        if self.base_dn:
            rdn = self.rdn
            if rdn:
                return self.rdn + ',' + self.base_dn
            else:
                # This is a special case, probably only relevant to the root
                # node.
                return self.base_dn
        else:
            # XXX: Does it make sense to support this?
            return self.rdn

    def get_all_attribute_names(self):
        return self._schema.attribute_aliases.keys()

    def get_attributes(self, include_aliases=False, include_not_set=False):
        """XXX"""
        result = {}
        if include_aliases:
            attrs = self._schema.attribute_aliases
        else:
            attrs = self._schema.attributes

        for attr_name in attrs:
            try:
                attr_value = self.__dict__[attr_name]
            except KeyError:
                if include_not_set:
                    # Because list is mutable, make sure it gets set.
                    attr_value = getattr(self, attr_name)
            else:
                result[attr_name] = attr_value
        # objectClass not in dict
        result['objectClass'] = self.objectClass
        return result

    def save(self, callback=None):
        conn = get_connection()
        if self._original_dn is None:
            # New object.
            modlist = []
            if self._rdn is None:
                rdn_values = {}
                for rdn_name in self.rdn_attributes:
                    try:
                        value = self.__dict__[rdn_name]
                    except KeyError:
                        raise AssertionError('RDN value %r is not set.' % (rdn_name,))
                    else:
                        rdn_values[rdn_name] = value
                new_rdn = RDN(**rdn_values)
            else:
                new_rdn = self._rdn
            for attr_name in self._schema.attributes:
                try:
                    value = self.__dict__[attr_name]
                except KeyError:
                    pass
                else:
                    # Don't include empty lists.
                    if not isinstance(value, list) or value:
                        modlist.append((attr_name, value))
            modlist.append(('objectClass', self.objectClass))
            oldap.callback._registry.pre_add(self, modlist, callback)
            conn.add_s(self.dn, modlist)
            oldap.callback._registry.post_add(self, modlist, callback)
            self._rdn = new_rdn
        else:
            if self._rdn != self._original_rdn:
                # Rename.
                oldap.callback._registry.pre_rename(self, self._original_dn,
                                                     self.rdn, callback)
                conn.rename_s(self._original_dn, self.rdn)
                oldap.callback._registry.post_rename(self, self._original_dn,
                                                      self.rdn, callback)
                # This will cause some unnecessary MOD_REPLACE because the
                # rename has already updated those values, but it's pretty
                # complex to safely avoid that.
            # Determine which attributes have changed.
            modlist = []
            # Callback modlist is different from the LDAP API because it will
            # also contain the old value for convenience.
            callback_modlist = []
            for attr_name in self._schema.attributes:
                try:
                    new_value = self.__dict__[attr_name]
                except KeyError:
                    new_value = None
                else:
                    # To simplify logic below for empty lists.
                    if new_value == []:
                        new_value = None

                old_value = self._original_values.get(attr_name, None)

                if new_value is None and old_value is not None:
                    # Removed.
                    op = ldap.MOD_DELETE
                elif old_value is None and new_value is not None:
                    # Added.
                    op = ldap.MOD_ADD
                elif new_value != old_value:
                    # Changed.
                    # This doesn't check for changes in sort order.
                    # Generally I don't think that should be a problem.
                    op = ldap.MOD_REPLACE

                modlist.append((op, attr_name, new_value))
                callback_modlist.append((op, attr_name, old_value, new_value))

            oldap.callback._registry.pre_modify(self, callback_modlist, callback)
            conn.modify_s(self.dn, modlist)
            oldap.callback._registry.post_modify(self, callback_modlist, callback)

        self._snapshot_original()
        return self

    def delete(self, callback=None):
        if self._original_dn is None:
            raise AssertionError('Unable to delete without first loading or saving.')
        if self._referrers:
            for referrer in self._referrers:
                if referrer.on_reference_delete is not None:
                    referrer.on_reference_delete(referrer, self, getattr(self, referrer.related_name))
        conn = get_connection()
        oldap.callback._registry.pre_delete(self, callback)
        conn.delete_s(self._original_dn)
        oldap.callback._registry.post_delete(self, callback)


class AttrDescriptor(object):

    def __init__(self, attr_schema):
        self.attr_schema = attr_schema
        self.name = attr_schema.names[0]

    def __get__(self, obj, objtype):
        if obj is None:
            raise AttributeError('Attribute access on class not supported.')
        try:
            return obj.__dict__[self.name]
        except KeyError:
            if self.attr_schema.single_value:
                return None
            else:
                # Because it's mutable.
                value = []
                obj.__dict__[self.name] = value
                return value

    def __set__(self, obj, value):
        if self.attr_schema.single_value:
            if isinstance(value, list):
                if len(value) > 1:
                    raise TypeError('Attribute %r is single-value only.' % (self.name,))
                else:
                    value = value[0]
        else:
            if not isinstance(value, list):
                raise TypeError('Attribute %r must be a list.' % (self.name,))
        obj.__dict__[self.name] = value


class ReadOnlyDescriptor(object):

    def __init__(self, value):
        self.value = value

    def __get__(self, obj, objtype):
        return self.value

    def __set__(self, obj, value):
        raise AttributeError('Value is read-only.')
